#!/usr/bin/env python3
"""Starts the call agent: one background Claude Code session (`claude --bg`) that listens to every call, and
records it in $AUX_HOME/agent.json so Aux can show its log and open it on demand.

  aux-agent [--project <dir>] [--model <m>] [--effort <level>]
  aux-agent --stop-self        # from inside the session: end this background session
  aux-agent --print-command    # print the command it would run, as JSON; start nothing

The session starts on the become-aux skill: it reads the wiki (aux-boot) and parks on `aux-listen` under a
Monitor, which passes on every call the operator is on. Tuple Aux.app (live mode) keeps exactly one alive
(WarmAgent.swift): it starts one at launch, replaces one whose heartbeat stopped, and retires one that has
heard calls once twenty minutes pass with no call, so an idle agent's context stays small. Each call's wiki
update is a session of its own (aux-after-call), so this one never stops to write.

Nothing is started while agent.json names a session that is alive and listening.

The session is the same interactive harness a Terminal window would run (Monitor, skills, auto mode), just
headless. `claude attach <id>` opens it in a terminal; `claude logs <id>` prints its screen; `claude stop <id>`
ends it. The notebook's menu offers the first.

`--effort` (low|medium|high|xhigh|max) sets how long the agent thinks per turn; it defaults to the word in
$AUX_HOME/agent-effort when that file exists, else medium. Most of a note's latency is that turn (measured
8-16 s), so a lower effort is the knob for quicker notes, at some cost to judgment.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path

# Inside an app bundle (Tuple.app/Contents/Helpers/Tuple Aux.app) a __pycache__ write is macOS's
# "python3 was prevented from modifying apps" prompt; the import never needs one.
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(__file__).resolve().parent))
from aux_context import (AGENT, AUX, LIVE_CALL, agent_instructions, agent_record, claude_plugin_args, link_bin,  # noqa: E402
                         plugin_skill, project, read_json, status, write_json)

HOME = Path.home()
BOOTING = 180    # seconds a new session gets to park before its record counts as dead
STALE = 300      # seconds without a heartbeat (aux-listen writes one every 15) after which a parked one is dead


def transcript_path(project, session_id):
    slug = re.sub(r"[^A-Za-z0-9]", "-", str(project))
    return HOME / ".claude" / "projects" / slug / f"{session_id}.jsonl"


def agents():
    try:
        p = subprocess.run(["claude", "agents", "--json"], capture_output=True, text=True, timeout=15)
        return json.loads(p.stdout) if p.returncode == 0 else []
    except (OSError, subprocess.TimeoutExpired, ValueError):
        return []


def parent(pid):
    try:
        p = subprocess.run(["ps", "-o", "ppid=", "-p", str(pid)], capture_output=True, text=True, timeout=5)
        return int(p.stdout.strip() or 0)
    except (OSError, subprocess.TimeoutExpired, ValueError):
        return 0


def stop_self():
    sessions = {s.get("pid"): s for s in agents() if s.get("pid")}
    pid = os.getpid()
    for _ in range(12):
        pid = parent(pid)
        if pid <= 1:
            break
        if pid in sessions:
            short = sessions[pid]["id"]
            print(f"aux-agent: stopping session {short} (pid {pid})", flush=True)
            # Detached, after a moment, so this command's own result reaches the agent first.
            subprocess.Popen(["/bin/sh", "-c", f"sleep 2; claude stop {short}"], start_new_session=True,
                             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            return 0
    print("aux-agent: not inside a background claude session; nothing to stop", file=sys.stderr)
    return 1


def listening(rec, now):
    """The recorded session is alive: its process exists, and it has parked recently or is still starting."""
    pid = rec.get("pid") or 0
    if rec.get("listeningAt"):
        fresh = now - rec["listeningAt"] < STALE
    else:
        fresh = now - rec.get("startedAt", 0) < BOOTING
    if not fresh:
        return False
    if not pid:
        return True   # just launched: `claude agents` has not reported its pid yet
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    return True


def main():
    if "--stop-self" in sys.argv:
        return stop_self()
    ap = argparse.ArgumentParser()
    ap.add_argument("--project", default=str(project()))
    ap.add_argument("--model", default="sonnet")   # Sonnet 5.5 (Ben, 2026-11-11: was Opus)
    effort_file = AUX / "agent-effort"
    ap.add_argument("--effort", default=effort_file.read_text().strip() if effort_file.exists() else "medium")
    # Settings › Agent shows exactly what the call agent is started with.
    ap.add_argument("--print-command", action="store_true")
    a = ap.parse_args()
    # The data folder goes in the prompt: a `claude --bg` session's shell does not inherit this process's
    # environment (the agent found $AUX_HOME empty, 2026-11-05).
    # The prompt goes before --allowedTools (a variadic flag that would otherwise swallow it). The allow rule
    # lets the agent run `aux-agent --stop-self` without a permission prompt.
    # The skills come from the bundled plugin, never from ~/.claude/skills.
    # The operator's own instructions (Settings › Agent) go in the system prompt, under the skills' rules.
    instructions = agent_instructions()
    cmd = ["claude", "--bg", "--model", a.model, *(["--effort", a.effort] if a.effort else []),
           *claude_plugin_args(), "--permission-mode", "auto",
           *(["--append-system-prompt", instructions] if instructions else []),
           f"{plugin_skill('become-aux')} --aux {AUX}",
           "--allowedTools", f"Bash({Path(__file__).resolve()}:*)"]
    if a.print_command:
        print(json.dumps({"cwd": a.project, "command": cmd}))
        return 0

    AUX.mkdir(parents=True, exist_ok=True)
    link_bin()
    log = open(AUX / "agent-launch.log", "a")
    log.write(f"\n--- {time.strftime('%Y-%m-%d %H:%M:%S')} effort={a.effort or 'default'}\n")
    held = agent_record()
    if held.get("id") and listening(held, time.time()):
        log.write(f"not launched: agent {held.get('id')} is listening\n")
        print(f"aux-agent: agent {held.get('id')} is already listening; not starting another")
        return 0
    # A call is on with nobody listening: the notebook says an agent is on its way.
    call = read_json(LIVE_CALL).get("call") or ""

    if not shutil.which("claude"):
        log.write("not launched: no claude command on the PATH\n")
        if call:
            status("error", "Claude Code isn't set up: Aux can't find the claude command. Open Aux's setup.", call)
        print("aux-agent: no claude command on the PATH; install Claude Code (Aux's setup has a step for it)", file=sys.stderr)
        return 1
    if call:
        status("starting", call=call)

    try:
        p = subprocess.run(cmd, cwd=a.project, capture_output=True, text=True, timeout=60)
    except (OSError, subprocess.TimeoutExpired) as e:
        log.write(f"launch failed: {e}\n")
        if call:
            status("error", "Couldn't start the call agent", call)
        return 1
    log.write(p.stdout + p.stderr)
    m = re.search(r"backgrounded\s*·\s*([0-9a-f]+)", p.stdout)
    if p.returncode != 0 or not m:
        if call:
            last = (p.stderr or p.stdout).strip().splitlines()[-1:]
            status("error", last[0][:80] if last else "Couldn't start the call agent", call)
        return 1
    short = m.group(1)

    # The full session id and pid come from the agents list, which can lag the launch by several seconds:
    # write the record right away and again whenever the list fills in more. The session writes to the same
    # record meanwhile (aux-boot: wikiLoadedAt; aux-listen: its heartbeat and the call), so only these fields
    # are set.
    def write(info):
        session_id = info.get("sessionId", short)
        existing = agent_record()
        record = existing if existing.get("id") == short else {"startedAt": int(time.time()), "state": "booting"}
        record.update({"id": short, "sessionId": session_id, "pid": info.get("pid"), "cwd": a.project,
                       "transcript": str(transcript_path(a.project, session_id))})
        write_json(AGENT, record)
        return record

    info = {}
    record = write(info)
    for _ in range(60):
        found = next((s for s in agents() if s.get("id") == short or str(s.get("sessionId", "")).startswith(short)), {})
        if found != info and found:
            info = found
            record = write(info)
        if info.get("pid"):
            break
        time.sleep(0.5)
    log.write(f"agent {short} session {record['sessionId']} pid {info.get('pid')}\n")
    print(f"aux-agent: {short} ({record['sessionId']})")
    return 0


if __name__ == "__main__":
    sys.exit(main())
